"use client" import * as React from 'react' import { useRouter } from 'next/navigation' import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy' import { Bot, Copy, Flag, Hand, Play, Save, Workflow as WorkflowIcon } from 'lucide-react' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { DataTable } from '@open-mercato/ui/backend/DataTable' import { LoadingMessage, ErrorMessage } from '@open-mercato/ui/backend/detail' import { EmptyState } from '@open-mercato/ui/primitives/empty-state' import { Button } from '@open-mercato/ui/primitives/button' import { StatusBadge, type StatusMap } from '@open-mercato/ui/primitives/status-badge' import { Textarea } from '@open-mercato/ui/primitives/textarea' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@open-mercato/ui/primitives/dialog' import { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall' import { updateCrud } from '@open-mercato/ui/backend/utils/crud' import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock' import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent' import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context' import { formatDateTime } from '../../../../components/types' import { PROCESS_STATUS_LABEL_KEY, type ProcessInstanceStatus } from '../../../../components/processTypes' import { processSingleAgentSchema, type ProcessMilestone, type ProcessRunTriggeredBy, type ProcessSingleAgent, type ProcessTrigger, } from '../../../../data/validators' import { manualTrigger, parseProcessTriggers, scheduleTriggers } from '../../../../lib/tasks/triggers' import { parseProcessMilestones } from '../../../../lib/tasks/milestones' import { readProcessOutcome, type ProcessOutcome } from '../../../../lib/tasks/outcome' import { ProcessOutcomeLink } from '../../../../components/ProcessOutcomeLink' import { TriggerEditor, invalidScheduleIndexes } from '../TriggerEditor' import { MilestoneEditor } from '../MilestoneEditor' import { generatedWorkflowId } from '../../../../lib/processes/materializeAgentWorkflow' type ProcessDefinitionDetail = { id: string name: string description: string | null /** Every process points at a workflow; the mode only records who authored it. */ workflowMode: 'single_agent' | 'workflow' workflowId: string | null singleAgent: ProcessSingleAgent | null inputDefaults: unknown /** The BOUND WORKFLOW's execution grant, read back from it — not stored here. */ grantedFeatures: string[] triggers: ProcessTrigger[] milestones: ProcessMilestone[] enabled: boolean updatedAt: string | null } type ProcessExecutionRow = { id: string status: string triggeredBy: ProcessRunTriggeredBy | null workflowInstanceId: string | null failureReason: string | null /** What the execution produced. Null on a research or monitoring process — optional by decision. */ outcome: ProcessOutcome | null /** Resolved server-side; null when the owning module is absent from this deployment. */ outcomeHref: string | null createdAt: string | null completedAt: string | null } /** * The execution status is DERIVED from the workflow instance, so an unknown value * renders neutral rather than being coerced — a status this page does not know is * not a failure. */ const statusVariant: StatusMap = { running: 'info', waiting_on_you: 'warning', question_open: 'warning', docs_requested: 'warning', fraud_hold: 'warning', auto_completing: 'info', auto_completed: 'success', completed: 'success', failed: 'error', cancelled: 'neutral', } function asString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } function mapDetail(raw: Record): ProcessDefinitionDetail | null { const id = asString(raw.id) if (!id) return null const granted = raw.grantedFeatures ?? raw.granted_features const workflowId = asString(raw.workflowId) ?? asString(raw.workflow_id) const singleAgentRaw = raw.singleAgent ?? raw.single_agent const parsedSingleAgent = singleAgentRaw ? processSingleAgentSchema.safeParse(singleAgentRaw) : null return { id, name: asString(raw.name) ?? id, description: asString(raw.description), workflowMode: workflowId === generatedWorkflowId(id) ? 'single_agent' : 'workflow', workflowId, singleAgent: parsedSingleAgent?.success ? parsedSingleAgent.data : null, inputDefaults: raw.inputDefaults ?? raw.input_defaults ?? null, grantedFeatures: Array.isArray(granted) ? granted.filter((value): value is string => typeof value === 'string') : [], triggers: parseProcessTriggers(raw.triggers), milestones: parseProcessMilestones(raw.milestones), enabled: (raw.enabled ?? true) !== false, updatedAt: asString(raw.updatedAt) ?? asString(raw.updated_at), } } /** The jsonb `triggered_by` shape, read defensively — a run row predating it has none. */ function mapTriggeredBy(raw: unknown): ProcessRunTriggeredBy | null { if (!raw || typeof raw !== 'object') return null const record = raw as Record const kind = record.kind if (kind !== 'schedule' && kind !== 'event' && kind !== 'manual' && kind !== 'system') return null const ref = typeof record.ref === 'string' && record.ref.length > 0 ? record.ref : undefined return kind === 'event' ? { kind, ref: ref ?? '' } : { kind, ref } } function mapRun(raw: Record): ProcessExecutionRow | null { const id = asString(raw.id) if (!id) return null return { id, status: asString(raw.status) ?? 'running', triggeredBy: mapTriggeredBy(raw.triggered_by ?? raw.triggeredBy), workflowInstanceId: asString(raw.workflow_instance_id) ?? asString(raw.workflowInstanceId), failureReason: asString(raw.failure_reason) ?? asString(raw.failureReason), outcome: readProcessOutcome(raw), outcomeHref: asString(raw.outcome_href) ?? asString(raw.outcomeHref), createdAt: asString(raw.created_at) ?? asString(raw.createdAt), completedAt: asString(raw.completed_at) ?? asString(raw.completedAt), } } export default function ProcessDefinitionDetailPage({ params }: { params?: { id?: string } }) { const t = useT() const locale = useLocale() const router = useRouter() const taskId = params?.id ?? '' const [task, setTask] = React.useState(null) const [triggerDraft, setTriggerDraft] = React.useState([]) const [milestoneDraft, setMilestoneDraft] = React.useState([]) const [runs, setRuns] = React.useState([]) const [isLoading, setIsLoading] = React.useState(true) const [error, setError] = React.useState(null) const [runOpen, setRunOpen] = React.useState(false) const [runInput, setRunInput] = React.useState('') const [runBusy, setRunBusy] = React.useState(false) const [triggerBusy, setTriggerBusy] = React.useState(false) const [milestoneBusy, setMilestoneBusy] = React.useState(false) const { runMutation, retryLastMutation } = useGuardedMutation<{ retryLastMutation: () => Promise }>({ contextId: 'agent_orchestrator.processDefinitions', blockedMessage: t('agent_orchestrator.processDefinitions.flash.blocked'), }) const loadDetail = React.useCallback(async () => { const call = await apiCall<{ definition?: Record }>( `/api/agent_orchestrator/processes/${encodeURIComponent(taskId)}`, undefined, { fallback: {} }, ) if (!call.ok || !call.result?.definition) { setError(t('agent_orchestrator.processDefinitions.detail.error')) return false } const detail = mapDetail(call.result.definition) setTask(detail) setTriggerDraft(detail?.triggers ?? []) setMilestoneDraft(detail?.milestones ?? []) return true }, [taskId, t]) const loadRuns = React.useCallback(async () => { const call = await apiCall<{ items?: Array> }>( `/api/agent_orchestrator/executions?processDefinitionId=${encodeURIComponent(taskId)}&pageSize=50`, undefined, { fallback: { items: [] } }, ) if (!call.ok) return setRuns( (Array.isArray(call.result?.items) ? call.result.items : []) .map(mapRun) .filter((row): row is ProcessExecutionRow => !!row), ) }, [taskId]) React.useEffect(() => { let cancelled = false async function load() { setIsLoading(true) setError(null) const ok = await loadDetail() if (cancelled) return if (ok) await loadRuns() if (!cancelled) setIsLoading(false) } if (taskId) void load() return () => { cancelled = true } }, [taskId, loadDetail, loadRuns]) useAppEvent('agent_orchestrator.process.execution.*', () => { void loadRuns() }) const openRunDialog = React.useCallback(() => { setRunInput(task?.inputDefaults ? JSON.stringify(task.inputDefaults, null, 2) : '{}') setRunOpen(true) }, [task]) const submitRun = React.useCallback(async () => { if (runBusy) return let parsed: Record | undefined const trimmed = runInput.trim() if (trimmed) { try { const value = JSON.parse(trimmed) if (value && typeof value === 'object' && !Array.isArray(value)) { parsed = value as Record } else { flash(t('agent_orchestrator.processDefinitions.run.invalidJson'), 'error') return } } catch { flash(t('agent_orchestrator.processDefinitions.run.invalidJson'), 'error') return } } setRunBusy(true) try { await runMutation({ operation: () => apiCallOrThrow(`/api/agent_orchestrator/processes/${encodeURIComponent(taskId)}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(parsed ? { input: parsed } : {}), }), context: { retryLastMutation }, mutationPayload: { taskId }, }) flash(t('agent_orchestrator.processDefinitions.run.started'), 'success') setRunOpen(false) await loadRuns() } catch (err) { flash(err instanceof Error ? err.message : t('agent_orchestrator.processDefinitions.run.error'), 'error') } finally { setRunBusy(false) } }, [runBusy, runInput, runMutation, retryLastMutation, taskId, t, loadRuns]) /** * Trigger edits mutate the PARENT definition, so the parent's optimistic-lock * header applies — no per-child override (there is no child record any more). */ const saveTriggers = React.useCallback(async () => { if (triggerBusy || !task) return if (invalidScheduleIndexes(triggerDraft).length > 0) { flash(t('agent_orchestrator.processDefinitions.form.errors.cronInvalid'), 'error') return } setTriggerBusy(true) try { await runMutation({ operation: () => withScopedApiRequestHeaders( buildOptimisticLockHeader(task.updatedAt), () => updateCrud('agent_orchestrator/processes', { id: task.id, name: task.name, workflowMode: task.workflowMode, workflowId: task.workflowId ?? undefined, singleAgent: task.singleAgent ?? undefined, grantedFeatures: task.grantedFeatures, triggers: triggerDraft, }), ), context: { retryLastMutation }, mutationPayload: { taskId }, }) flash(t('agent_orchestrator.processDefinitions.triggers.saved'), 'success') await loadDetail() } catch (err) { if (surfaceRecordConflict(err, t)) { await loadDetail() return } flash(err instanceof Error ? err.message : t('agent_orchestrator.processDefinitions.triggers.error'), 'error') } finally { setTriggerBusy(false) } }, [triggerBusy, task, triggerDraft, runMutation, retryLastMutation, taskId, t, loadDetail]) /** * Milestone rows mutate the PARENT definition, so the parent's * optimistic-lock header is the one that applies — no per-child override, * because there is no child record. */ const saveMilestones = React.useCallback(async () => { if (milestoneBusy || !task) return setMilestoneBusy(true) try { await runMutation({ operation: () => withScopedApiRequestHeaders( buildOptimisticLockHeader(task.updatedAt), () => updateCrud('agent_orchestrator/processes', { id: task.id, name: task.name, workflowMode: task.workflowMode, workflowId: task.workflowId ?? undefined, singleAgent: task.singleAgent ?? undefined, grantedFeatures: task.grantedFeatures, milestones: milestoneDraft, }), ), context: { retryLastMutation }, mutationPayload: { taskId }, }) flash(t('agent_orchestrator.processDefinitions.milestones.saved'), 'success') await loadDetail() } catch (err) { if (surfaceRecordConflict(err, t)) { await loadDetail() return } flash(err instanceof Error ? err.message : t('agent_orchestrator.processDefinitions.milestones.error'), 'error') } finally { setMilestoneBusy(false) } }, [milestoneBusy, task, milestoneDraft, runMutation, retryLastMutation, taskId, t, loadDetail]) const runColumns = React.useMemo[]>( () => [ { accessorKey: 'status', header: t('agent_orchestrator.processDefinitions.runs.col.status'), cell: ({ row }) => ( {t(PROCESS_STATUS_LABEL_KEY[row.original.status as ProcessInstanceStatus] ?? row.original.status)} ), }, { accessorKey: 'triggeredBy', header: t('agent_orchestrator.processDefinitions.runs.col.triggeredBy'), cell: ({ row }) => { const source = row.original.triggeredBy if (!source) return — return ( {t(`agent_orchestrator.processDefinitions.runs.triggeredBy.${source.kind}`)} {source.ref ? ( {source.ref} ) : null} ) }, }, { accessorKey: 'createdAt', header: t('agent_orchestrator.processDefinitions.runs.col.started'), cell: ({ row }) => {formatDateTime(row.original.createdAt, locale) ?? '—'}, }, { accessorKey: 'completedAt', header: t('agent_orchestrator.processDefinitions.runs.col.completed'), cell: ({ row }) => {formatDateTime(row.original.completedAt, locale) ?? '—'}, }, { id: 'outcome', header: t('agent_orchestrator.processDefinitions.runs.col.outcome'), cell: ({ row }) => row.original.outcome ? ( ) : ( — ), }, { id: 'result', header: t('agent_orchestrator.processDefinitions.runs.col.result'), cell: ({ row }) => { if (row.original.failureReason) { return {row.original.failureReason} } if (row.original.workflowInstanceId) { return ( ) } return — }, }, ], [t, locale, router], ) const [origin, setOrigin] = React.useState('') React.useEffect(() => { setOrigin(window.location.origin) }, []) const copyToClipboard = React.useCallback( async (value: string) => { try { await navigator.clipboard.writeText(value) flash(t('agent_orchestrator.processDefinitions.api.copied'), 'success') } catch { flash(t('agent_orchestrator.processDefinitions.api.copyFailed'), 'error') } }, [t], ) if (isLoading) { return ( ) } if (error || !task) { return ( ) } const TargetIcon = task.workflowMode === 'single_agent' ? Bot : WorkflowIcon const targetId = task.workflowMode === 'single_agent' ? task.singleAgent?.agentId ?? task.workflowId : task.workflowId const schedules = scheduleTriggers(task.triggers) // Run-now mirrors the server gate exactly: no declared manual trigger, no // hand-start (the route 403s), so the button must not promise one. const allowsManual = manualTrigger(task.triggers) !== null // API trigger facts — the primary machine entry point for process definitions. // `origin` resolves client-side only, so the snippet shows the real host. const apiPath = `/api/agent_orchestrator/processes/${task.id}/executions` const apiUrl = `${origin}${apiPath}` const inputExample = task.inputDefaults && typeof task.inputDefaults === 'object' && !Array.isArray(task.inputDefaults) ? (task.inputDefaults as Record) : {} const curlExample = [ `curl -X POST '${apiUrl}' \\`, ` -H 'x-api-key: ' \\`, ` -H 'content-type: application/json' \\`, ` -d '${JSON.stringify({ input: inputExample, idempotencyKey: 'unique-key-123' })}'`, ].join('\n') return (

{task.name}

{task.description ? (

{task.description}

) : null}
{targetId ?? '—'} {!task.enabled ? ( {t('agent_orchestrator.processDefinitions.detail.disabled')} ) : null} {schedules.map((trigger, index) => ( {trigger.cron} {!trigger.enabled ? ` (${t('agent_orchestrator.processDefinitions.list.schedulePaused')})` : ''} ))}
{!allowsManual ? ( {t('agent_orchestrator.processDefinitions.triggers.manual.required')} ) : null}
{task.grantedFeatures.length > 0 ? (

{t('agent_orchestrator.processDefinitions.detail.grantedFeatures')}

{task.grantedFeatures.map((feature) => ( {feature} ))}
) : null}

{t('agent_orchestrator.processDefinitions.api.title')}

{t('agent_orchestrator.processDefinitions.api.description')}

POST {apiUrl}

{t('agent_orchestrator.processDefinitions.api.curlTitle')}

              {curlExample}
            

{t('agent_orchestrator.processDefinitions.api.authNote')}

{t('agent_orchestrator.processDefinitions.api.responseNote')}

{t('agent_orchestrator.processDefinitions.triggers.title')}

{t('agent_orchestrator.processDefinitions.triggers.description')}

{ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() void saveTriggers() } if (event.key === 'Escape') { event.preventDefault() setTriggerDraft(task.triggers) } }} >
{true ? (

{t('agent_orchestrator.processDefinitions.milestones.title')}

{t('agent_orchestrator.processDefinitions.milestones.description')}

{ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() void saveMilestones() } if (event.key === 'Escape') { event.preventDefault() setMilestoneDraft(task.milestones) } }} >
) : null}

{t('agent_orchestrator.processDefinitions.runs.title')}

{runs.length === 0 ? ( ) : ( columns={runColumns} data={runs} sortable /> )}
{ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() void submitRun() } }} > {t('agent_orchestrator.processDefinitions.run.title')} {t('agent_orchestrator.processDefinitions.run.description')}